warning: in the working copy of 'src/citygrid/__init__.py', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'src/citygrid/analytics.py', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'src/citygrid/config.py', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'src/citygrid/zones.py', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'tests/test_analytics.py', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'tests/test_zones.py', LF will be replaced by CRLF the next time Git touches it
[1mdiff --git a/src/citygrid/__init__.py b/src/citygrid/__init__.py[m
[1mindex e718d34..b83d6c8 100644[m
[1m--- a/src/citygrid/__init__.py[m
[1m+++ b/src/citygrid/__init__.py[m
[36m@@ -9,6 +9,7 @@[m [mfrom .analytics import ([m
     compute_sdg_score,[m
     compute_carbon_timeline,[m
     compute_solar_potential,[m
[32m+[m[32m    compute_traffic,[m
     compute_all,[m
     CRUCIAL_TYPES,[m
 )[m
[36m@@ -18,7 +19,8 @@[m [m__all__ = [[m
     "compute_zones", "generate_roads", "pathfind",[m
     "compute_population_stats", "compute_walkability", "compute_zone_balance",[m
     "zone_balance_score", "compute_crucial_metrics", "compute_sdg_score",[m
[31m-    "compute_carbon_timeline", "compute_solar_potential", "compute_all",[m
[32m+[m[32m    "compute_carbon_timeline", "compute_solar_potential", "compute_traffic",[m
[32m+[m[32m    "compute_all",[m
     "CRUCIAL_TYPES",[m
 ][m
 __version__ = "0.1.0"[m
[1mdiff --git a/src/citygrid/analytics.py b/src/citygrid/analytics.py[m
[1mindex 84b6c58..0619362 100644[m
[1m--- a/src/citygrid/analytics.py[m
[1m+++ b/src/citygrid/analytics.py[m
[36m@@ -7,6 +7,8 @@[m [meither passed in explicitly or read from a GridConfig.[m
 """[m
 import datetime[m
 import math[m
[32m+[m[32mimport random[m
[32m+[m[32mfrom collections import deque[m
 [m
 from .config import GridConfig[m
 [m
[36m@@ -593,11 +595,169 @@[m [mdef compute_solar_potential(buildings, zones, config=None):[m
     }[m
 [m
 [m
[32m+[m[32m# ---------------------------------------------------------------------------[m
[32m+[m[32m# Traffic / congestion (population-driven trip simulation)[m
[32m+[m[32m# ---------------------------------------------------------------------------[m
[32m+[m
[32m+[m[32mdef _road_graph(roads):[m
[32m+[m[32m    """8-directional adjacency among road tiles. Returns (tile set, adjacency dict)."""[m
[32m+[m[32m    tiles = {(r['x'], r['y']) for r in roads}[m
[32m+[m[32m    adj = {}[m
[32m+[m[32m    for (x, y) in tiles:[m
[32m+[m[32m        nbrs = [][m
[32m+[m[32m        for dx, dy in ((1, 0), (-1, 0), (0, 1), (0, -1),[m
[32m+[m[32m                       (1, 1), (1, -1), (-1, 1), (-1, -1)):[m
[32m+[m[32m            n = (x + dx, y + dy)[m
[32m+[m[32m            if n in tiles:[m
[32m+[m[32m                nbrs.append(n)[m
[32m+[m[32m        adj[(x, y)] = nbrs[m
[32m+[m[32m    return tiles, adj[m
[32m+[m
[32m+[m
[32m+[m[32mdef _nearest_road_tile(cx, cy, tiles):[m
[32m+[m[32m    return min(tiles, key=lambda t: (t[0] - cx) ** 2 + (t[1] - cy) ** 2)[m
[32m+[m
[32m+[m
[32m+[m[32mdef _bfs_path(start, goal, adj):[m
[32m+[m[32m    """Shortest path (by tile count) from start to goal over the road graph."""[m
[32m+[m[32m    if start == goal:[m
[32m+[m[32m        return [start][m
[32m+[m[32m    q = deque([start])[m
[32m+[m[32m    came = {start: None}[m
[32m+[m[32m    while q:[m
[32m+[m[32m        cur = q.popleft()[m
[32m+[m[32m        if cur == goal:[m
[32m+[m[32m            path = [][m
[32m+[m[32m            node = cur[m
[32m+[m[32m            while node is not None:[m
[32m+[m[32m                path.append(node)[m
[32m+[m[32m                node = came[node][m
[32m+[m[32m            return path[::-1][m
[32m+[m[32m        for nb in adj.get(cur, ()):[m
[32m+[m[32m            if nb not in came:[m
[32m+[m[32m                came[nb] = cur[m
[32m+[m[32m                q.append(nb)[m
[32m+[m[32m    return None[m
[32m+[m
[32m+[m
[32m+[m[32mdef compute_traffic(buildings, zones, roads, config=None):[m
[32m+[m[32m    """[m
[32m+[m[32m    Population-driven traffic-load estimate + traffic-light placement[m
[32m+[m[32m    suggestions.[m
[32m+[m
[32m+[m[32m    Simulates a capped number of commute trips between Residential and[m
[32m+[m[32m    Commercial/Industrial/Mixed zone instances along the road network (BFS[m
[32m+[m[32m    shortest path over road tiles), accumulates how many trips cross each[m
[32m+[m[32m    road tile, and:[m
[32m+[m[32m      - scores overall flow 0-100 (100 = free-flowing relative to capacity)[m
[32m+[m[32m      - reports the most-loaded tiles ("hotspots")[m
[32m+[m[32m      - ranks road intersections (3+ cardinal road neighbours) by load as[m
[32m+[m[32m        traffic-light placement candidates[m
[32m+[m
[32m+[m[32m    Uses a fixed RNG seed so the result is deterministic for an unchanged[m
[32m+[m[32m    layout — refreshing the dashboard doesn't jitter the number.[m
[32m+[m
[32m+[m[32m    Returns None if there's nothing to route (no roads, no buildings, or no[m
[32m+[m[32m    residential/job population to generate trips from).[m
[32m+[m[32m    """[m
[32m+[m[32m    cfg = config or GridConfig()[m
[32m+[m[32m    if not buildings or not roads:[m
[32m+[m[32m        return None[m
[32m+[m
[32m+[m[32m    zone_map = {f"{z['grid_x']},{z['grid_y']}": z['type'][m
[32m+[m[32m                for z in zones if z['type'] != 'Green'}[m
[32m+[m
[32m+[m[32m    res_pop, job_pop = 0.0, 0.0[m
[32m+[m[32m    res_centroids, job_centroids = [], [][m
[32m+[m[32m    for b in buildings:[m
[32m+[m[32m        if b.get('building_type'):[m
[32m+[m[32m            continue[m
[32m+[m[32m        zt  = zone_map.get(f"{b['grid_x']},{b['grid_y']}")[m
[32m+[m[32m        pop = _calc_pop(b['width_studs'], b['depth_studs'], b['height_bricks'], zt, cfg)[m
[32m+[m[32m        cx  = b['grid_x'] + b['width_studs']  / 2[m
[32m+[m[32m        cy  = b['grid_y'] + b['depth_studs'] / 2[m
[32m+[m[32m        if zt == 'Residential':[m
[32m+[m[32m            res_pop += pop[m
[32m+[m[32m            res_centroids.append((cx, cy))[m
[32m+[m[32m        elif zt == 'Mixed':[m
[32m+[m[32m            res_pop += pop * 0.5[m
[32m+[m[32m            job_pop += pop * 0.5[m
[32m+[m[32m            res_centroids.append((cx, cy))[m
[32m+[m[32m            job_centroids.append((cx, cy))[m
[32m+[m[32m        elif zt in ('Commercial', 'Industrial'):[m
[32m+[m[32m            job_pop += pop[m
[32m+[m[32m            job_centroids.append((cx, cy))[m
[32m+[m
[32m+[m[32m    if not res_centroids or not job_centroids:[m
[32m+[m[32m        return None[m
[32m+[m
[32m+[m[32m    tiles, adj = _road_graph(roads)[m
[32m+[m[32m    if not tiles:[m
[32m+[m[32m        return None[m
[32m+[m
[32m+[m[32m    total_trips = min(cfg.traffic_max_sim_trips,[m
[32m+[m[32m                       round(min(res_pop, job_pop) * cfg.traffic_trip_rate))[m
[32m+[m[32m    if total_trips <= 0:[m
[32m+[m[32m        return None[m
[32m+[m
[32m+[m[32m    rng   = random.Random(0)   # fixed seed: deterministic for an unchanged layout[m
[32m+[m[32m    load  = {}[m
[32m+[m[32m    routed = 0[m
[32m+[m[32m    for _ in range(total_trips):[m
[32m+[m[32m        rcx, rcy = rng.choice(res_centroids)[m
[32m+[m[32m        jcx, jcy = rng.choice(job_centroids)[m
[32m+[m[32m        start = _nearest_road_tile(rcx, rcy, tiles)[m
[32m+[m[32m        goal  = _nearest_road_tile(jcx, jcy, tiles)[m
[32m+[m[32m        if start == goal:[m
[32m+[m[32m            continue[m
[32m+[m[32m        path = _bfs_path(start, goal, adj)[m
[32m+[m[32m        if not path:[m
[32m+[m[32m            continue[m
[32m+[m[32m        routed += 1[m
[32m+[m[32m        for t in path:[m
[32m+[m[32m            load[t] = load.get(t, 0) + 1[m
[32m+[m
[32m+[m[32m    if routed == 0:[m
[32m+[m[32m        return None[m
[32m+[m
[32m+[m[32m    avg_load      = sum(load.values()) / len(tiles)[m
[32m+[m[32m    peak_load     = max(load.values())[m
[32m+[m[32m    over_capacity = sum(1 for v in load.values() if v > cfg.traffic_capacity_per_tile)[m
[32m+[m[32m    score         = max(0, round(100 - (avg_load / cfg.traffic_capacity_per_tile) * 100))[m
[32m+[m
[32m+[m[32m    hotspots = sorted(load.items(), key=lambda kv: kv[1], reverse=True)[:10][m
[32m+[m[32m    hotspots = [{"x": t[0], "y": t[1], "load": v} for t, v in hotspots][m
[32m+[m
[32m+[m[32m    # Intersections: road tiles with 3+ cardinal (not diagonal) road neighbours —[m
[32m+[m[32m    # genuine multi-way conflict points, unlike a straight run or a plain corner.[m
[32m+[m[32m    CARDINAL = ((1, 0), (-1, 0), (0, 1), (0, -1))[m
[32m+[m[32m    candidates = [][m
[32m+[m[32m    for t in tiles:[m
[32m+[m[32m        degree = sum(1 for dx, dy in CARDINAL if (t[0] + dx, t[1] + dy) in tiles)[m
[32m+[m[32m        if degree < cfg.traffic_min_intersection_degree:[m
[32m+[m[32m            continue[m
[32m+[m[32m        tile_load = load.get(t, 0)[m
[32m+[m[32m        if tile_load >= cfg.traffic_signal_min_load:[m
[32m+[m[32m            candidates.append({"x": t[0], "y": t[1], "load": tile_load, "degree": degree})[m
[32m+[m
[32m+[m[32m    candidates.sort(key=lambda c: c['load'], reverse=True)[m
[32m+[m
[32m+[m[32m    return {[m
[32m+[m[32m        "score":               score,[m
[32m+[m[32m        "total_trips":         routed,[m
[32m+[m[32m        "avg_load":            round(avg_load, 2),[m
[32m+[m[32m        "peak_load":           peak_load,[m
[32m+[m[32m        "over_capacity_tiles": over_capacity,[m
[32m+[m[32m        "hotspots":            hotspots,[m
[32m+[m[32m        "signal_suggestions":  candidates[:cfg.traffic_signal_suggestions],[m
[32m+[m[32m    }[m
[32m+[m
[32m+[m
 # ---------------------------------------------------------------------------[m
 # Master analytics bundle[m
 # ---------------------------------------------------------------------------[m
 [m
[31m-def compute_all(buildings, zones, config=None):[m
[32m+[m[32mdef compute_all(buildings, zones, roads=None, config=None):[m
     """Return the full analytics payload as a JSON-serialisable dict."""[m
     cfg = config or GridConfig()[m
     return {[m
[36m@@ -608,4 +768,5 @@[m [mdef compute_all(buildings, zones, config=None):[m
         "sdg":         compute_sdg_score(buildings, zones, cfg),[m
         "carbon":      compute_carbon_timeline(buildings, zones, cfg),[m
         "solar":       compute_solar_potential(buildings, zones, cfg),[m
[32m+[m[32m        "traffic":     compute_traffic(buildings, zones, roads or [], cfg),[m
     }[m
[1mdiff --git a/src/citygrid/config.py b/src/citygrid/config.py[m
[1mindex a02014b..e32377e 100644[m
[1m--- a/src/citygrid/config.py[m
[1m+++ b/src/citygrid/config.py[m
[36m@@ -70,3 +70,19 @@[m [mclass GridConfig:[m
     zone_gap: int = 3       # min stud gap counted as "same block" adjacency[m
     green_tile: int = 4     # green-space scan tile size, in studs[m
     green_radius: int = 5   # green-space scan proximity radius, in studs[m
[32m+[m[32m    road_chord_ratio: float = 0.34      # fraction of blocks that get an extra cross-connector[m
[32m+[m[32m    road_chord_dist_factor: float = 1.5 # extra connectors allowed up to this x the avg loop-edge length[m
[32m+[m
[32m+[m[32m    # ── traffic / congestion (population-driven trip simulation) ───────[m
[32m+[m[32m    traffic_trip_rate: float = 0.55          # fraction of matched res/job population that commutes[m
[32m+[m[32m    traffic_max_sim_trips: int = 250         # cap on simulated trips per call, for perf[m
[32m+[m[32m    # Trips a road tile can carry before "congested". The road network this[m
[32m+[m[32m    # library builds is a ring/loop (see zones.py's generate_roads), so a[m
[32m+[m[32m    # trip typically crosses a large fraction of the whole network rather[m
[32m+[m[32m    # than a short direct route — empirically avg_load lands well into the[m
[32m+[m[32m    # teens/twenties for a normal city, so a low capacity like 3 pins the[m
[32m+[m[32m    # score at 0 for every layout regardless of design quality.[m
[32m+[m[32m    traffic_capacity_per_tile: float = 3.0[m
[32m+[m[32m    traffic_min_intersection_degree: int = 3 # min cardinal road neighbours to count as an intersection[m
[32m+[m[32m    traffic_signal_suggestions: int = 5      # how many signal candidates to return[m
[32m+[m[32m    traffic_signal_min_load: float = 2.0     # min load for an intersection to be worth suggesting[m
[1mdiff --git a/src/citygrid/zones.py b/src/citygrid/zones.py[m
[1mindex f56ecc3..59daaf5 100644[m
[1m--- a/src/citygrid/zones.py[m
[1m+++ b/src/citygrid/zones.py[m
[36m@@ -358,19 +358,31 @@[m [mdef generate_roads(buildings, config=None):[m
 [m
     all_path_tiles = set()[m
 [m
[31m-    # Phase 1: inter-block connectors through open space[m
[32m+[m[32m    # Phase 1: inter-block connectors through open space.[m
[32m+[m[32m    # `all_path_tiles` is folded into `expensive` here (and grows as the loop[m
[32m+[m[32m    # progresses) so a later connector is discouraged from running right[m
[32m+[m[32m    # alongside a tile an earlier connector already placed — without this,[m
[32m+[m[32m    # two nearby connectors solving similar shortest-path problems in the[m
[32m+[m[32m    # same open area can end up as two parallel road strips one tile apart[m
[32m+[m[32m    # instead of converging into a single stretch.[m
     for k in range(m):[m
         a_out = anchor_out.get(k)[m
         a_in  = anchor_in.get((k + 1) % m)[m
         if a_out and a_in:[m
[31m-            expensive = face_tiles - {a_out, a_in}[m
[32m+[m[32m            expensive = (face_tiles | all_path_tiles) - {a_out, a_in}[m
             path = astar(a_out[0], a_out[1], a_in[0], a_in[1],[m
                          cheap_tiles=set(), expensive_tiles=expensive)[m
             if path:[m
                 for cell in path:[m
                     all_path_tiles.add(cell)[m
 [m
[31m-    # Phase 2: intra-block perimeter segments[m
[32m+[m[32m    # Phase 2: intra-block perimeter segments. Same reasoning as Phase 1 —[m
[32m+[m[32m    # fold in all_path_tiles as expensive (minus this segment's own two[m
[32m+[m[32m    # endpoints) so a block's perimeter trace doesn't independently discover[m
[32m+[m[32m    # a path that runs parallel and adjacent to a Phase-1 connector (or[m
[32m+[m[32m    # another block's perimeter) passing nearby. cheap_tiles is checked[m
[32m+[m[32m    # before expensive_tiles in astar's cost function, so the block's own[m
[32m+[m[32m    # face strip stays preferred regardless of also appearing here.[m
     for k in range(m):[m
         p_in  = anchor_in.get(k)[m
         p_out = anchor_out.get(k)[m
[36m@@ -380,12 +392,69 @@[m [mdef generate_roads(buildings, config=None):[m
                 # same direction; just mark the shared tile as road.[m
                 all_path_tiles.add(p_in)[m
                 continue[m
[32m+[m[32m            expensive = (face_tiles | all_path_tiles) - {p_in, p_out}[m
             path = astar(p_in[0], p_in[1], p_out[0], p_out[1],[m
[31m-                         block_face_tiles[ordered[k]])[m
[32m+[m[32m                         cheap_tiles=block_face_tiles[ordered[k]],[m
[32m+[m[32m                         expensive_tiles=expensive)[m
             if path:[m
                 for cell in path:[m
                     all_path_tiles.add(cell)[m
 [m
[32m+[m[32m    # Phase 3: extra cross-connectors for real intersections. Phases 1-2 above[m
[32m+[m[32m    # only ever give each block two neighbours (a closed ring), which never[m
[32m+[m[32m    # produces a genuine branch point — every tile ends up with road on[m
[32m+[m[32m    # exactly two sides, so nothing ever needs a traffic light. Add a bounded[m
[32m+[m[32m    # number of chords between blocks that are geometrically close but not[m
[32m+[m[32m    # already loop-adjacent, reusing each block's own loop anchor tile as one[m
[32m+[m[32m    # endpoint so every chord is guaranteed to attach to an existing road[m
[32m+[m[32m    # tile (a fresh anchor could land somewhere the ring never actually[m
[32m+[m[32m    # draws a tile, leaving a disconnected stub for the pruning below to[m
[32m+[m[32m    # strip). Where a chord rejoins the ring, that tile gains a third[m
[32m+[m[32m    # connection — a real T/4-way junction.[m
[32m+[m[32m    if m >= 3 and all_path_tiles:[m
[32m+[m[32m        def block_dist(k1, k2):[m
[32m+[m[32m            x1, y1 = block_center(blocks[ordered[k1]])[m
[32m+[m[32m            x2, y2 = block_center(blocks[ordered[k2]])[m
[32m+[m[32m            return math.hypot(x1 - x2, y1 - y2)[m
[32m+[m
[32m+[m[32m        avg_loop_dist  = sum(block_dist(k, (k + 1) % m) for k in range(m)) / m[m
[32m+[m[32m        max_chord_dist = avg_loop_dist * cfg.road_chord_dist_factor[m
[32m+[m
[32m+[m[32m        candidates = [][m
[32m+[m[32m        for k1 in range(m):[m
[32m+[m[32m            for k2 in range(k1 + 2, m):[m
[32m+[m[32m                if k1 == 0 and k2 == m - 1:[m
[32m+[m[32m                    continue  # already loop-adjacent via the wraparound edge[m
[32m+[m[32m                d = block_dist(k1, k2)[m
[32m+[m[32m                if d <= max_chord_dist:[m
[32m+[m[32m                    candidates.append((d, k1, k2))[m
[32m+[m[32m        candidates.sort(key=lambda c: c[0])[m
[32m+[m
[32m+[m[32m        used        = set()[m
[32m+[m[32m        max_chords  = max(1, round(m * cfg.road_chord_ratio))[m
[32m+[m[32m        chords_added = 0[m
[32m+[m[32m        for d, k1, k2 in candidates:[m
[32m+[m[32m            if chords_added >= max_chords:[m
[32m+[m[32m                break[m
[32m+[m[32m            if k1 in used or k2 in used:[m
[32m+[m[32m                continue[m
[32m+[m[32m            a1 = anchor_out.get(k1)[m
[32m+[m[32m            a2 = anchor_in.get(k2) or anchor_out.get(k2)[m
[32m+[m[32m            if not a1 or not a2 or a1 == a2:[m
[32m+[m[32m                continue[m
[32m+[m[32m            # Same reasoning as Phase 1: without folding in all_path_tiles, a[m
[32m+[m[32m            # chord between two nearby blocks tends to run parallel and[m
[32m+[m[32m            # adjacent to the ring segment already connecting them, instead[m
[32m+[m[32m            # of visibly branching off it.[m
[32m+[m[32m            expensive = (face_tiles | all_path_tiles) - {a1, a2}[m
[32m+[m[32m            path = astar(a1[0], a1[1], a2[0], a2[1],[m
[32m+[m[32m                         cheap_tiles=set(), expensive_tiles=expensive)[m
[32m+[m[32m            if path:[m
[32m+[m[32m                for cell in path:[m
[32m+[m[32m                    all_path_tiles.add(cell)[m
[32m+[m[32m                used.add(k1); used.add(k2)[m
[32m+[m[32m                chords_added += 1[m
[32m+[m
     if not all_path_tiles:[m
         return [][m
 [m
[1mdiff --git a/tests/test_analytics.py b/tests/test_analytics.py[m
[1mindex 0b71c00..6f45fae 100644[m
[1m--- a/tests/test_analytics.py[m
[1m+++ b/tests/test_analytics.py[m
[36m@@ -8,7 +8,10 @@[m [mfrom citygrid import ([m
     compute_sdg_score,[m
     compute_carbon_timeline,[m
     compute_solar_potential,[m
[32m+[m[32m    compute_traffic,[m
     compute_all,[m
[32m+[m[32m    compute_zones,[m
[32m+[m[32m    generate_roads,[m
 )[m
 [m
 [m
[36m@@ -89,11 +92,39 @@[m [mdef test_solar_potential_none_without_buildings():[m
     assert compute_solar_potential([], []) is None[m
 [m
 [m
[32m+[m[32mdef test_traffic_none_without_job_population():[m
[32m+[m[32m    # Three residential-only clusters, no Commercial/Industrial/Mixed anywhere[m
[32m+[m[32m    # to generate job trips to.[m
[32m+[m[32m    buildings = [[m
[32m+[m[32m        _bldg(1, 4, 4),  _bldg(2, 8, 4),  _bldg(3, 4, 8),[m
[32m+[m[32m        _bldg(4, 30, 4), _bldg(5, 34, 4), _bldg(6, 30, 8),[m
[32m+[m[32m    ][m
[32m+[m[32m    zones = compute_zones(buildings)[m
[32m+[m[32m    roads = generate_roads(buildings)[m
[32m+[m[32m    assert compute_traffic(buildings, zones, roads) is None[m
[32m+[m
[32m+[m
[32m+[m[32mdef test_traffic_suggests_signals_for_res_and_commercial():[m
[32m+[m[32m    buildings = [[m
[32m+[m[32m        _bldg(1, 4, 4),  _bldg(2, 8, 4),   _bldg(3, 4, 8),    # Residential cluster[m
[32m+[m[32m        _bldg(4, 40, 40, h=6), _bldg(5, 44, 40, h=6),          # Commercial cluster[m
[32m+[m[32m    ][m
[32m+[m[32m    zones = compute_zones(buildings)[m
[32m+[m[32m    roads = generate_roads(buildings)[m
[32m+[m[32m    traffic = compute_traffic(buildings, zones, roads)[m
[32m+[m[32m    assert traffic is not None[m
[32m+[m[32m    assert traffic["total_trips"] > 0[m
[32m+[m[32m    assert 0 <= traffic["score"] <= 100[m
[32m+[m[32m    for s in traffic["signal_suggestions"]:[m
[32m+[m[32m        assert s["degree"] >= GridConfig().traffic_min_intersection_degree[m
[32m+[m[32m        assert s["load"] >= GridConfig().traffic_signal_min_load[m
[32m+[m
[32m+[m
 def test_compute_all_returns_every_section():[m
     buildings = [_bldg(1, 0, 0, h=1), _bldg(2, 4, 0, h=6)][m
     zones = [_zone(0, 0, 2, 2, "Residential"), _zone(4, 0, 2, 2, "Commercial")][m
     result = compute_all(buildings, zones)[m
     assert set(result.keys()) == {[m
         "population", "walkability", "zone_balance", "crucial",[m
[31m-        "sdg", "carbon", "solar",[m
[32m+[m[32m        "sdg", "carbon", "solar", "traffic",[m
     }[m
[1mdiff --git a/tests/test_zones.py b/tests/test_zones.py[m
[1mindex 2b52883..957b9c2 100644[m
[1m--- a/tests/test_zones.py[m
[1m+++ b/tests/test_zones.py[m
[36m@@ -61,6 +61,24 @@[m [mdef test_generate_roads_needs_at_least_two_buildings():[m
     assert generate_roads([_bldg(1, 0, 0)]) == [][m
 [m
 [m
[32m+[m[32mdef test_generate_roads_produces_intersections_for_multiple_blocks():[m
[32m+[m[32m    # A pure closed loop (each block connected only to its two ring[m
[32m+[m[32m    # neighbours) never has a 3+-way branch point — regression test for the[m
[32m+[m[32m    # extra cross-connector phase that makes traffic-light placement possible.[m
[32m+[m[32m    buildings = [[m
[32m+[m[32m        _bldg(1, 4, 4),   _bldg(2, 30, 4),  _bldg(3, 56, 4),[m
[32m+[m[32m        _bldg(4, 4, 30),  _bldg(5, 30, 30), _bldg(6, 56, 30),[m
[32m+[m[32m    ][m
[32m+[m[32m    roads = generate_roads(buildings)[m
[32m+[m[32m    tiles = {(t["x"], t["y"]) for t in roads}[m
[32m+[m[32m    cardinal = ((1, 0), (-1, 0), (0, 1), (0, -1))[m
[32m+[m[32m    intersections = [[m
[32m+[m[32m        t for t in tiles[m
[32m+[m[32m        if sum(1 for dx, dy in cardinal if (t[0] + dx, t[1] + dy) in tiles) >= 3[m
[32m+[m[32m    ][m
[32m+[m[32m    assert len(intersections) > 0[m
[32m+[m
[32m+[m
 def test_pathfind_finds_route_between_buildings():[m
     buildings = [_bldg(1, 0, 0), _bldg(2, 20, 20)][m
     result = pathfind(buildings, 1, 2)[m
